Documentation
inbox/Multi-Tenancy in Dynaplex.md
Multi-Tenancy in Dynaplex
Overview
Dynaplex implements row-level multi-tenancy using Entity Framework Core global query filters. This approach provides automatic tenant isolation at the database level, preventing data leakage between tenants while maintaining a single database with shared schema.
Architecture
Row-Level Multi-Tenancy
Each tenant-scoped entity includes a TenantId column (Guid) that identifies which tenant owns the data. EF Core global query filters automatically restrict queries to return only data for the current tenant.
┌─────────────────────────────────────────┐
│ PostgreSQL Database │
│ ┌───────────────────────────────────┐ │
│ │ Schema: catalog │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ items table │ │ │
│ │ ├─────────────────────────────┤ │ │
│ │ │ id │ tenant_id │ │ │
│ │ │ guid-1 │ tenant-a │ │ │ ← Tenant A's data
│ │ │ guid-2 │ tenant-a │ │ │
│ │ │ guid-3 │ tenant-b │ │ │ ← Tenant B's data
│ │ │ guid-4 │ tenant-b │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
↓ EF Core Query Filters
┌──────────────────────┐ ┌──────────────────────┐
│ Tenant A Context │ │ Tenant B Context │
│ ─────────────────── │ │ ─────────────────── │
│ Returns only: │ │ Returns only: │
│ - guid-1 │ │ - guid-3 │
│ - guid-2 │ │ - guid-4 │
└──────────────────────┘ └──────────────────────┘
Comparison to Other Approaches
| Approach | Dynaplex Uses | Pros | Cons |
|---|---|---|---|
| Row-Level (TenantId column) | ✅ Yes | • Single database • Easy to manage • Cost-effective • Simple backups |
• All tenants share resources • Requires careful query filtering |
| Schema per Tenant | ❌ No | • Better isolation • Can customize per tenant |
• Complex migration management • Not supported well in EF Core |
| Database per Tenant | ❌ No | • Complete isolation • Easy to backup/restore individual tenants |
• High maintenance cost • Difficult to scale |
Note: Dynaplex uses schemas for component isolation (catalog, identity, spatial, etc.), NOT for tenant isolation.
Implementation Components
1. ICurrentTenantService
Provides access to the current tenant context. Must be implemented by the application.
// Interface (in Acsis.Dynaplex)
public interface ICurrentTenantService
{
Guid TenantId { get; }
}
// Example implementation for multi-tenant production (with authentication)
public class CurrentTenantService : ICurrentTenantService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentTenantService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Guid TenantId
{
get
{
// Get tenant ID from JWT claims
var user = _httpContextAccessor.HttpContext?.User;
var tenantClaim = user?.FindFirst("tenant_id")?.Value;
if (string.IsNullOrEmpty(tenantClaim))
throw new UnauthorizedAccessException("No tenant context available");
return Guid.Parse(tenantClaim);
}
}
}
// Default implementation for single-tenant development (no authentication required)
// Available in Acsis.Dynaplex - see "Single-Tenant Development Mode" section below
public class DefaultTenantService : ICurrentTenantService
{
// Auto-detects and returns the single tenant ID
// Throws clear error if multiple tenants exist
}
2. ConfigureTenantFilters Extension
Automatically applies global query filters to all entities with a TenantId property.
// In DatabaseExtensions.cs
public static void ConfigureTenantFilters(this ModelBuilder modelBuilder, Guid tenantId)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var tenantIdProperty = entityType.FindProperty("TenantId");
if (tenantIdProperty == null || tenantIdProperty.ClrType != typeof(Guid))
continue;
// Build filter: e => e.TenantId == tenantId
var parameter = Expression.Parameter(entityType.ClrType, "e");
var body = Expression.Equal(
Expression.Property(parameter, "TenantId"),
Expression.Constant(tenantId));
var lambda = Expression.Lambda(body, parameter);
entityType.SetQueryFilter(lambda);
}
}
3. DbContext Configuration
Each DbContext injects ICurrentTenantService and configures tenant filters:
using Acsis.Dynaplex;
using Microsoft.EntityFrameworkCore;
public class CatalogDb : DbContext
{
private readonly ICurrentTenantService? _tenantService;
public CatalogDb(DbContextOptions<CatalogDb> options) : base(options) { }
public CatalogDb(
DbContextOptions<CatalogDb> options,
ICurrentTenantService tenantService)
: base(options)
{
_tenantService = tenantService;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ... other configurations ...
// Add indexes for performance
modelBuilder.Entity<Item>().HasIndex(i => i.TenantId);
// Configure tenant filters (if service is available)
if (_tenantService != null)
{
modelBuilder.ConfigureTenantFilters(_tenantService.TenantId);
}
// Schema isolation (component boundaries)
modelBuilder.ConfigureSchemaIsolation(SCHEMA);
}
}
Single-Tenant Development Mode
Overview
For development and single-tenant deployments, Dynaplex provides DefaultTenantService - an intelligent fallback implementation that automatically detects when only one tenant exists in the system.
Why Use DefaultTenantService?
Problem: During early development, you want tenant isolation infrastructure in place, but don't want to implement full authentication and tenant resolution yet.
Solution: DefaultTenantService provides zero-configuration tenant context when exactly one tenant exists, while failing fast with a clear error message when multiple tenants are added (signaling it's time to implement proper authentication).
How It Works
// In Acsis.Dynaplex/DefaultTenantService.cs
public class DefaultTenantService : ICurrentTenantService
{
public Guid TenantId
{
get
{
// Queries database on first access
// Caches result for request lifetime
var tenants = dbContext.Tenants.Select(t => t.Id).ToList();
return tenants.Count switch
{
0 => throw new InvalidOperationException(
"No tenants found. Initialize system with at least one tenant."),
1 => tenants[0], // ✅ Returns the single tenant ID
_ => throw new InvalidOperationException(
$"Multiple tenants detected ({tenants.Count} tenants). " +
"Please implement proper ICurrentTenantService with authentication.")
};
}
}
}
Registration Pattern
Use TryAddScoped so a real implementation can override:
// In Program.cs or service registration
services.TryAddScoped<ICurrentTenantService>(sp =>
{
var factory = sp.GetRequiredService<IDbContextFactory<IdentityDb>>();
return DefaultTenantService.Create(factory);
});
// Later, when adding real multi-tenant support, just register normally:
// This will take precedence over the TryAdd registration
services.AddScoped<ICurrentTenantService, HttpContextTenantService>();
Benefits
- Zero Configuration: Works automatically for single-tenant scenarios
- Fail Fast: Clear error when multiple tenants exist
- No Boilerplate: Don't need authentication setup during development
- Easy Migration: Just register a real implementation to override
- Tenant Filtering Active: All query filters work from day one
Development Workflow
┌─────────────────────────────────────────────┐
│ Stage 1: Single-Tenant Development │
├─────────────────────────────────────────────┤
│ • DefaultTenantService registered │
│ • One tenant in database │
│ • Auto-returns that tenant ID │
│ • No authentication needed │
│ ✅ Tenant filtering ACTIVE │
└─────────────────────────────────────────────┘
↓
↓ (Add second tenant to database)
↓
┌─────────────────────────────────────────────┐
│ Stage 2: Transition Triggered │
├─────────────────────────────────────────────┤
│ • DefaultTenantService throws exception │
│ • Clear error: "Multiple tenants detected" │
│ • Forces you to implement authentication │
│ ⚠️ Application won't start │
└─────────────────────────────────────────────┘
↓
↓ (Implement proper tenant service)
↓
┌─────────────────────────────────────────────┐
│ Stage 3: Multi-Tenant Production │
├─────────────────────────────────────────────┤
│ • HttpContextTenantService registered │
│ • Gets tenant ID from JWT claims │
│ • Proper authentication required │
│ ✅ Tenant filtering ACTIVE │
└─────────────────────────────────────────────┘
Example: Complete Setup
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Register IdentityDb with DbContext factory for pooling
builder.Services.AddDbContextFactory<IdentityDb>(options =>
options.UseNpgsql(connectionString));
// Register DefaultTenantService as fallback
// Uses TryAdd so it can be overridden by a real implementation
builder.Services.TryAddScoped<ICurrentTenantService>(sp =>
{
var factory = sp.GetRequiredService<IDbContextFactory<IdentityDb>>();
return DefaultTenantService.Create(factory);
});
// Register other DbContexts - they'll automatically get tenant service injected
builder.Services.AddDbContext<CatalogDb>((sp, options) =>
{
options.UseNpgsql(catalogConnectionString);
// Tenant service will be injected via constructor
});
var app = builder.Build();
// Seed database with initial tenant if needed
using (var scope = app.Services.CreateScope())
{
var identityDb = scope.ServiceProvider.GetRequiredService<IdentityDb>();
if (!identityDb.Tenants.Any())
{
identityDb.Tenants.Add(new Tenant
{
Id = Guid.NewGuid(),
Name = "Default Tenant",
Slug = "default"
});
identityDb.SaveChanges();
}
}
app.Run();
Testing DefaultTenantService
[Fact]
public void DefaultTenantService_SingleTenant_ReturnsCorrectId()
{
// Arrange
var tenantId = Guid.NewGuid();
var factory = CreateDbContextFactory(tenantId);
var service = DefaultTenantService.Create(factory);
// Act
var result = service.TenantId;
// Assert
Assert.Equal(tenantId, result);
}
[Fact]
public void DefaultTenantService_MultipleTenants_ThrowsException()
{
// Arrange
var factory = CreateDbContextFactoryWithMultipleTenants();
var service = DefaultTenantService.Create(factory);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => service.TenantId);
Assert.Contains("Multiple tenants detected", exception.Message);
}
[Fact]
public void DefaultTenantService_NoTenants_ThrowsException()
{
// Arrange
var factory = CreateDbContextFactoryWithNoTenants();
var service = DefaultTenantService.Create(factory);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => service.TenantId);
Assert.Contains("No tenants found", exception.Message);
}
Migration to Multi-Tenant
When you're ready to support multiple tenants:
Implement HttpContextTenantService:
public class HttpContextTenantService : ICurrentTenantService { private readonly IHttpContextAccessor _httpContextAccessor; public HttpContextTenantService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public Guid TenantId { get { var tenantClaim = _httpContextAccessor.HttpContext?.User ?.FindFirst("tenant_id")?.Value; if (string.IsNullOrEmpty(tenantClaim)) throw new UnauthorizedAccessException("No tenant context"); return Guid.Parse(tenantClaim); } } }Register the new service:
// This overrides the TryAddScoped registration services.AddScoped<ICurrentTenantService, HttpContextTenantService>();Add authentication:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { /* configure JWT */ }); app.UseAuthentication(); app.UseAuthorization();Include tenant_id claim in JWTs:
var claims = new[] { new Claim("tenant_id", user.TenantId.ToString()), new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), // ... other claims };
When to Use DefaultTenantService
✅ DO USE when:
- Building a new application (greenfield)
- Want tenant isolation from day one
- Not ready to implement authentication yet
- Deploying as single-tenant initially
- Need a smooth path to multi-tenancy
❌ DON'T USE when:
- Already have multiple tenants
- Authentication is already implemented
- Running in production with multiple customers
- Need tenant resolution from sources other than database (e.g., subdomain, header)
Security Benefits
Automatic Filtering
With global query filters, tenant isolation happens automatically:
// ✅ BEFORE: Manual filtering (error-prone)
var items = await catalogDb.Items
.Where(i => i.TenantId == currentTenantId) // Easy to forget!
.ToListAsync();
// ✅ AFTER: Automatic filtering (safe)
var items = await catalogDb.Items.ToListAsync(); // Only current tenant's data
Protection Against Data Leakage
// Scenario: Developer forgets to filter by tenant
// ❌ WITHOUT global filters (DANGEROUS)
var allItems = await catalogDb.Items.ToListAsync();
// Returns ALL items from ALL tenants → DATA LEAK
// ✅ WITH global filters (SAFE)
var allItems = await catalogDb.Items.ToListAsync();
// Returns only current tenant's items → SAFE
Explicit Bypass for Admin Operations
// Admin scenario: Need to access all tenants' data
var allItems = await catalogDb.Items
.IgnoreQueryFilters() // Explicit bypass
.ToListAsync();
Performance Considerations
Indexes on TenantId
CRITICAL: Every entity with TenantId must have an index for optimal query performance.
// In OnModelCreating
modelBuilder.Entity<Item>().HasIndex(i => i.TenantId);
modelBuilder.Entity<User>().HasIndex(u => u.TenantId);
// ... etc for all tenant-scoped entities
Without indexes, queries will perform table scans filtering by tenant, which is slow for large datasets.
Query Performance
-- With index on tenant_id (FAST)
SELECT * FROM catalog.items WHERE tenant_id = 'abc-123...';
-- Uses index scan → milliseconds
-- Without index (SLOW)
SELECT * FROM catalog.items WHERE tenant_id = 'abc-123...';
-- Uses sequential scan → seconds/minutes for large tables
Component Coverage
All Dynaplex components with tenant-scoped data have been updated:
| Component | Tenant-Scoped Entities | Status |
|---|---|---|
| identity | User, Group, Role, Permission, RefreshToken | ✅ Configured |
| catalog | Item | ✅ Configured |
| spatial | Movement (others are global) | ✅ Configured |
| printing | PrintJob, PrintJobData | ✅ Configured |
| transport | Shipment, Delivery, ShipmentItemAllocation | ✅ Configured |
| workflow | Workflow, WorkflowCategory, WorkflowEvent, WorkflowRun | ✅ Configured |
| bbu | Customer, DwellTime, others | ✅ Configured |
Note: Not all entities are tenant-scoped. For example:
Tenanttable itself (in Identity) - not tenant-filteredPlatformType,Country,Region- shared across tenants- Configuration tables - shared system-wide
Migration Impact
Adding Tenant Filters
Adding tenant filters is non-breaking and requires no database migration:
- ✅ No schema changes
- ✅ No new columns added
- ✅ Only affects EF Core query generation
- ✅ Existing migrations remain valid
Future Migrations
When creating new migrations after adding tenant filters, the filters will automatically apply to all queries, including migration validation queries.
Testing
Unit Testing
Mock ICurrentTenantService in tests:
[Fact]
public async Task GetItems_ReturnsOnlyCurrentTenantItems()
{
// Arrange
var tenantId = Guid.NewGuid();
var tenantService = new Mock<ICurrentTenantService>();
tenantService.Setup(x => x.TenantId).Returns(tenantId);
var options = new DbContextOptionsBuilder<CatalogDb>()
.UseInMemoryDatabase("test")
.Options;
using var context = new CatalogDb(options, tenantService.Object);
// Add items for different tenants
context.Items.Add(new Item { Id = Guid.NewGuid(), TenantId = tenantId });
context.Items.Add(new Item { Id = Guid.NewGuid(), TenantId = Guid.NewGuid() });
await context.SaveChangesAsync();
// Act
var items = await context.Items.ToListAsync();
// Assert
Assert.Single(items); // Only returns current tenant's item
}
Integration Testing
Test with multiple tenant contexts:
[Fact]
public async Task TenantIsolation_PreventsCrossTenantAccess()
{
var tenant1 = Guid.NewGuid();
var tenant2 = Guid.NewGuid();
// Create items for tenant 1
using (var context = CreateContext(tenant1))
{
context.Items.Add(new Item { Id = Guid.NewGuid(), TenantId = tenant1 });
await context.SaveChangesAsync();
}
// Try to access with tenant 2 context
using (var context = CreateContext(tenant2))
{
var items = await context.Items.ToListAsync();
Assert.Empty(items); // Tenant 2 cannot see tenant 1's data
}
}
Common Scenarios
Scenario 1: Background Jobs
Background jobs need special handling since there's no HTTP context:
public class ReportGenerationJob
{
private readonly IDbContextFactory<CatalogDb> _contextFactory;
public async Task GenerateReportForTenant(Guid tenantId)
{
// Create a scoped tenant service
var tenantService = new ScopedTenantService(tenantId);
// Create context with specific tenant
using var context = new CatalogDb(
_contextFactory.CreateDbContext().Database.GetDbConnection(),
tenantService);
var items = await context.Items.ToListAsync();
// Generates report for specific tenant
}
}
public class ScopedTenantService : ICurrentTenantService
{
public ScopedTenantService(Guid tenantId) => TenantId = tenantId;
public Guid TenantId { get; }
}
Scenario 2: System Migrations
Migrations and database initialization should use parameterless constructor:
// Migration design-time factory
public class CatalogDbFactory : IDesignTimeDbContextFactory<CatalogDb>
{
public CatalogDb CreateDbContext(string[] args)
{
var builder = new DbContextOptionsBuilder<CatalogDb>();
builder.UseNpgsql("connection-string");
// Use parameterless constructor - no tenant filtering
return new CatalogDb(builder.Options);
}
}
Scenario 3: Admin Tools
Admin operations that need cross-tenant access:
public class AdminService
{
private readonly CatalogDb _catalogDb;
public async Task<List<Item>> GetAllItemsAcrossAllTenants()
{
// Explicitly bypass tenant filters for admin operation
return await _catalogDb.Items
.IgnoreQueryFilters()
.ToListAsync();
}
public async Task<List<Item>> GetItemsForSpecificTenant(Guid tenantId)
{
// Filter manually when bypassing automatic filtering
return await _catalogDb.Items
.IgnoreQueryFilters()
.Where(i => i.TenantId == tenantId)
.ToListAsync();
}
}
Troubleshooting
Issue: Queries return no data
Symptom: Queries return empty results even though data exists
Cause: ICurrentTenantService not injected or returns wrong tenant ID
Solution:
// Check if tenant service is registered in DI
services.AddScoped<ICurrentTenantService, CurrentTenantService>();
// Verify tenant ID in service
var tenantId = _tenantService.TenantId;
Console.WriteLine($"Current tenant: {tenantId}");
Issue: Queries return all tenants' data
Symptom: Queries return data from all tenants
Causes:
- DbContext created with parameterless constructor (no tenant service)
- Entity doesn't have
TenantIdproperty - Tenant filters not configured in
OnModelCreating
Solution:
// Always use constructor with tenant service at runtime
services.AddDbContext<CatalogDb>((sp, options) => {
options.UseNpgsql(connectionString);
// Ensure tenant service is injected
var tenantService = sp.GetRequiredService<ICurrentTenantService>();
return new CatalogDb(options, tenantService);
});
Issue: Slow query performance
Symptom: Queries are slower than expected
Cause: Missing index on TenantId column
Solution:
// Add index in OnModelCreating
modelBuilder.Entity<YourEntity>().HasIndex(e => e.TenantId);
// Or via migration
migrationBuilder.CreateIndex(
name: "ix__your_table__tenant_id",
schema: "your_schema",
table: "your_table",
column: "tenant_id");
Best Practices
✅ DO
- Always inject
ICurrentTenantServicein runtime contexts - Add indexes on
TenantIdfor all tenant-scoped entities - Use
IgnoreQueryFilters()explicitly when cross-tenant access is needed - Document which entities are tenant-scoped in your domain model
- Test tenant isolation in integration tests
- Validate tenant ID before allowing operations
❌ DON'T
- Don't manually filter by
TenantId- let global filters handle it - Don't use
ICurrentTenantServicein migrations - use parameterless constructor - Don't forget indexes - performance will suffer
- Don't bypass filters without explicit justification
- Don't share DbContext instances across tenant boundaries
Summary
Dynaplex's multi-tenancy implementation provides:
- ✅ Automatic tenant isolation via EF Core global query filters
- ✅ Protection against data leakage by default
- ✅ Performance optimization through indexed queries
- ✅ Flexibility for admin/system operations with explicit bypass
- ✅ Simplicity - developers don't need to remember to filter every query
The implementation is secure by default while remaining flexible for edge cases.